Webpack Tree Shaking 原理解析

WebpackTree Shaking模块化

前言

WebpackV2 版本支持 Tree Shaking,其目的是在打包过程中,消除代码中未使用的死代码,以减小最终构建产物的大小。 类似于 DCE1,Tree Shaking 同样是一种死代码消除,区别是相对于传统的 DCE 基于代码流分析,Tree Shaking 是作用于模块级别的,基于 ESM 的静态分析能力。 在介绍 Webpack Tree Shaking 之前,我们先来简单回顾下 ESMCJS 的使用以及它们之间的区别。

区别

CommonJS

CommonJS 是一个专门为服务端设计的模块化规范,其主要实现是 NodeJS。它规定每一个文件都是一个独立的模块,拥有着自己独立的作用域。模块通过 require 导入,它支持路径运行时计算,其依赖关系无法在编译时确定。

const env = process.env.NODE_ENV || 'prod'

const path = env === 'prod' ? './prod.js' : './dev.js'

const config = require(path)

使用 exports / module.exports 导出内部成员。

// 动态导出示例
if (condition) {
  module.exports = { funcA }
} else {
  module.exports = { funcB }
}

Webpack 无法在构建阶段准确追踪动态导出和导入的引用关系,因此 无法对 CJS 模块实施 Tree Shaking。

ES Module

ESM 又称 ES Module,是 ECMAScript 标准引入的模块化规范,相对于 CommonJS 是动态导入,ESM 的模块是静态的,可以在编译时就确定其依赖关系。它采用 import / export 来导入/导出成员,它们必须在顶层声明(不能在条件语句里)。

// 静态导入导出(可分析)
import { utils } from './lib.js' // ✅ 路径为字面量
export const PI = 3.14 // ✅ 顶层声明

ESM 同样支持动态导入,具体参考 import() 2,不过由于其动态性可能会影响 Tree Shaking,你可以使用相应的魔法注释3来减小动态导入对 Tree Shaking 的影响。

// math.js
export const add = (a, b) => a + b
export const multiply = (a, b) => a * b

// 动态导入(仅使用 add)
import('./math.js').then(({ add }) => add(2, 3))

静态结构允许构建工具在打包时精确建立模块依赖图谱,识别未被引用的导出项,为代码消除提供必要条件。

通过对比可见,ESM 的静态模块特性是 Tree Shaking 能够实现的技术基石。接下来我们将深入解析 Webpack 实现 Tree Shaking 的具体工作机制。

Webpack Tree Shaking 流程解析

Webpack 的 Tree Shaking 实现可分为四个关键阶段,详细可以参考4

阶段流程图

1.模块解析阶段

  • 构建模块依赖图谱
  • 识别所有导出项(harmony exports
  • 记录导入引用关系

2.标记阶段

   /***/ (__unused_webpack_module, __unused_webpack___webpack_exports__, __webpack_require__) => {
      eval(
        '/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./utils */ "./src/utils.js");\n/* harmony import */ var _side_effect__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./side-effect */ "./src/side-effect.js");\n\n\n\nconsole.log((0,_utils__WEBPACK_IMPORTED_MODULE_1__.add)(2, 3));\n\n\n//# sourceURL=webpack://webpack/./src/index.js?'
      )

      /***/
    },
  • 遍历所有导出项
  • 标记被引用的导出为 harmony export
  • 未引用项标记为 unused harmony export

3.副作用分析

  • 默认认为所有模块都有副作用
  • 通过配置声明无副作用模块
  • 对无副作用且无引用的模块进行删除
package.json
{
  "sideEffects": ["./src/side-effects.js"]
}

Webpack 会根据 package.json / module.rulessideEffects 的配置来判断哪些文件具有副作用,可以通过设置 sideEffects 来告知 Webpack 安心的删除未使用到的代码。

4.代码消除阶段

  • 删除未被标记的导出声明
  • 移除无副作用未引用模块
  • 保留有副作用的未引用模块

代码演示

// utils.js
export const add = (a, b) => a + b
export const sub = (a, b) => a - b // 该函数不会被使用

// index.js
import { add } from './utils'
import './side-effect'

console.log(add(2, 3))

// side-effect.js
export const say = (name) => {
  console.log(name)
  return name
}
window.x = 'sideEffect'
webpack.config.js
module.exports = {
  mode: 'development',
  optimization: {
    usedExports: true,
  },
}

可以看到,当我们设置 modedevelopment 时,此时的构建产物出现了 harmony / unused harmony 的标记, 你可以尝试切换 modeproduction 去看看构建产物的变化。

main.js
/******/ var __webpack_modules__ = {
  /***/ './src/index.js':
    /*!**********************!*\
  !*** ./src/index.js ***!
  \**********************/
    /***/ (__unused_webpack_module, __unused_webpack___webpack_exports__, __webpack_require__) => {
      eval(
        '/* harmony import */ var _utils__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./utils */ "./src/utils.js");\n/* harmony import */ var _side_effect__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./side-effect */ "./src/side-effect.js");\n\n\n\nconsole.log((0,_utils__WEBPACK_IMPORTED_MODULE_1__.add)(2, 3));\n\n\n//# sourceURL=webpack://webpack/./src/index.js?'
      )

      /***/
    },

  /***/ './src/side-effect.js':
    /*!****************************!*\
  !*** ./src/side-effect.js ***!
  \****************************/
    /***/ (__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
      eval(
        '/* unused harmony export say */\nconst say = (name) => {\n  console.log(name);\n  return name;\n};\nwindow.x = "sideEffect";\n\n\n//# sourceURL=webpack://webpack/./src/side-effect.js?'
      )

      /***/
    },

  /***/ './src/utils.js':
    /*!**********************!*\
  !*** ./src/utils.js ***!
  \**********************/
    /***/ (__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
      eval(
        '/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   add: () => (/* binding */ add)\n/* harmony export */ });\n/* unused harmony exports sub, calculator */\n// 显式导出独立函数\nconst add = (a, b) => a + b;\nconst sub = (a, b) => a - b; // 该函数不会被使用\n\n// 导出对象(需要特殊处理)\nconst calculator = {\n  multiply(a, b) {\n    return a * b;\n  },\n  divide(a, b) {\n    return a / b;\n  },\n};\n\n\n//# sourceURL=webpack://webpack/./src/utils.js?'
      )

      /***/
    },

  /******/
}

补充

  1. usedExports 的作用

usedExports 会为每个模块标记 harmony / unused harmony,你可以通过 usedExports 设置成 true 来开启。

"usedExports: false 会禁用标记系统,但生产模式下默认启用的 minimize 仍会触发 Terser 的静态分析删除,这是两种不同的死代码消除机制。

sideEffects 不同的是,usedExports 仅提供标记功能,标记本身不会删除代码,而是为类似 Terser 等压缩工具提供元数据。最终删除由压缩工具代码生成阶段完成。具体可以参考下方流程图:

mermaid
  1. concatenateModules

concatenateModules 是一种 Scope Hoisting 跨模块优化技术将多个模块合并到一个共享的闭包中,用于进一步优化代码体积,它默认在 production 模式下开启。

import { add } from './utils'
import './side-effect'

console.log(add(2, 3))

/**
 * 开启 concatenateModules 后的构建产物
 * */
;(() => {
  'use strict'
  console.log(5)
})()

优化 Tree shaking 的建议

  1. 确保使用 ESM: 只有 ESM 模块是静态的,Tree shaking 才能正常工作。尽量使用 importexport,避免使用 CommonJS 模块,需要注意的是使用 babel 确保它不会将 ESM 模块转换成其他模块。
  2. 标记副作用: 在 package.json 中使用 sideEffects 字段,告知打包工具哪些模块是无副作用的,这样可以更好地执行 Tree shaking。
  3. 避免动态代码: 动态导入和动态依赖(如 require())会阻碍 Tree shaking 的工作,尽量避免这些模式。
  4. 通过魔法注释 /*#__PURE__*/ 将函数标记为无副作用5

参考

  1. DCE

  2. import 动态导入

  3. Magic Comments

  4. Webpack 原理系列九:Tree-Shaking 实现原理

  5. Webpack 官网 Tree Shaking 解释